๐๏ธGitะฏัะฐ๐๏ธ
Commit 338ea0e8ea1c6c0af5d06c66505ef15638f3a091
Parents : 578dd1c
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Date : 2026-06-03T19:45:52-05:00
feat: FTS5 full-text message search (#5373)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Changes
20 files changed, 1554 insertions(+), 2 deletions(-)
Diff
diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index 1ddfce32a1..9a7c48713f 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -1130,6 +1130,7 @@ scanning_network
screen_on_for
scroll_to_bottom
search_emoji
+search_messages
secondary
secondary_channel_position_feature
secondary_no_telemetry
diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts
index 5f68d18481..0b19a02a70 100644
--- a/androidApp/build.gradle.kts
+++ b/androidApp/build.gradle.kts
@@ -305,4 +305,6 @@ dependencies {
testImplementation(libs.compose.multiplatform.ui.test)
testImplementation(libs.androidx.test.ext.junit)
testImplementation(libs.androidx.glance.appwidget)
+ // JVM variant provides the host-platform native library for BundledSQLiteDriver under Robolectric
+ testRuntimeOnly("androidx.sqlite:sqlite-bundled-jvm:2.6.2")
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt
index c47fe5bf15..1fa6024092 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt
@@ -22,6 +22,7 @@ import androidx.paging.PagingData
import androidx.paging.map
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.withContext
@@ -139,6 +140,7 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
rssi = packet.rssi,
hopsAway = packet.hopsAway,
filtered = filtered,
+ messageText = packet.text.orEmpty(),
)
insertRoomPacket(packetToSave)
}
@@ -278,6 +280,7 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
rssi = packet.rssi,
hopsAway = packet.hopsAway,
filtered = filtered,
+ messageText = packet.text.orEmpty(),
)
insertRoomPacket(packetToSave)
}
@@ -510,6 +513,54 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
sfpp_hash = sfppHash,
)
+ override fun searchMessages(query: String, contactKey: String?, getNode: (String?) -> Node): Flow<List<Message>> {
+ val sanitized = sanitizeFtsQuery(query)
+ if (sanitized.isBlank()) return flowOf(emptyList())
+ return dbManager.currentDb.flatMapLatest { db ->
+ kotlinx.coroutines.flow.flow {
+ val dao = db.packetDao()
+ val packets =
+ if (contactKey != null) {
+ dao.searchMessagesInConversation(sanitized, contactKey)
+ } else {
+ dao.searchMessages(sanitized)
+ }
+ emit(
+ packets.map { packet ->
+ val node = getNode(packet.data.from)
+ val isFromLocal =
+ node.user.id == DataPacket.ID_LOCAL ||
+ (packet.myNodeNum != 0 && node.num == packet.myNodeNum)
+ Message(
+ uuid = packet.uuid,
+ receivedTime = packet.received_time,
+ node = node,
+ text = packet.data.text.orEmpty(),
+ fromLocal = isFromLocal,
+ time = org.meshtastic.core.model.util.getShortDateTime(packet.data.time),
+ snr = packet.snr,
+ rssi = packet.rssi,
+ hopsAway = packet.hopsAway,
+ read = packet.read,
+ status = packet.data.status,
+ routingError = packet.routingError,
+ packetId = packet.packetId,
+ emojis = emptyList(),
+ replyId = packet.data.replyId,
+ )
+ },
+ )
+ }
+ }
+ }
+
+ /**
+ * Sanitizes a user query for FTS5 by wrapping each token in double quotes. This escapes FTS5 special characters (*,
+ * -, NEAR, etc.) while still allowing multi-word searches as implicit AND queries.
+ */
+ private fun sanitizeFtsQuery(query: String): String =
+ query.split("\\s+".toRegex()).filter { it.isNotBlank() }.joinToString(" ") { "\"${it.replace("\"", "")}\"" }
+
companion object {
private const val CONTACTS_PAGE_SIZE = 30
private const val MESSAGES_PAGE_SIZE = 50
diff --git a/core/database/build.gradle.kts b/core/database/build.gradle.kts
index 1f56b06c7e..794007b1e5 100644
--- a/core/database/build.gradle.kts
+++ b/core/database/build.gradle.kts
@@ -53,6 +53,8 @@ kotlin {
val androidHostTest by getting {
dependencies {
implementation(libs.androidx.sqlite.bundled)
+ // JVM variant provides the host-platform native for BundledSQLiteDriver
+ runtimeOnly("androidx.sqlite:sqlite-bundled-jvm:2.6.2")
implementation(libs.androidx.room.testing)
implementation(libs.androidx.test.ext.junit)
implementation(libs.junit)
diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/39.json b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/39.json
new file mode 100644
index 0000000000..4ac92bf0db
--- /dev/null
+++ b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/39.json
@@ -0,0 +1,1095 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 39,
+ "identityHash": "69fb4477a86e5ba8c47876cbb3035839",
+ "entities": [
+ {
+ "tableName": "my_node",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL, `model` TEXT, `firmwareVersion` TEXT, `couldUpdate` INTEGER NOT NULL, `shouldUpdate` INTEGER NOT NULL, `currentPacketId` INTEGER NOT NULL, `messageTimeoutMsec` INTEGER NOT NULL, `minAppVersion` INTEGER NOT NULL, `maxChannels` INTEGER NOT NULL, `hasWifi` INTEGER NOT NULL, `deviceId` TEXT, `pioEnv` TEXT, PRIMARY KEY(`myNodeNum`))",
+ "fields": [
+ {
+ "fieldPath": "myNodeNum",
+ "columnName": "myNodeNum",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "model",
+ "columnName": "model",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "firmwareVersion",
+ "columnName": "firmwareVersion",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "couldUpdate",
+ "columnName": "couldUpdate",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "shouldUpdate",
+ "columnName": "shouldUpdate",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "currentPacketId",
+ "columnName": "currentPacketId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "messageTimeoutMsec",
+ "columnName": "messageTimeoutMsec",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "minAppVersion",
+ "columnName": "minAppVersion",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "maxChannels",
+ "columnName": "maxChannels",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hasWifi",
+ "columnName": "hasWifi",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deviceId",
+ "columnName": "deviceId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "pioEnv",
+ "columnName": "pioEnv",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "myNodeNum"
+ ]
+ }
+ },
+ {
+ "tableName": "nodes",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `user` BLOB NOT NULL, `long_name` TEXT, `short_name` TEXT, `position` BLOB NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `snr` REAL NOT NULL, `rssi` INTEGER NOT NULL, `last_heard` INTEGER NOT NULL, `device_metrics` BLOB NOT NULL, `channel` INTEGER NOT NULL, `via_mqtt` INTEGER NOT NULL, `hops_away` INTEGER NOT NULL, `is_favorite` INTEGER NOT NULL, `is_ignored` INTEGER NOT NULL DEFAULT 0, `is_muted` INTEGER NOT NULL DEFAULT 0, `environment_metrics` BLOB NOT NULL, `power_metrics` BLOB NOT NULL, `paxcounter` BLOB NOT NULL, `public_key` BLOB, `notes` TEXT NOT NULL DEFAULT '', `manually_verified` INTEGER NOT NULL DEFAULT 0, `node_status` TEXT, `last_transport` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`num`))",
+ "fields": [
+ {
+ "fieldPath": "num",
+ "columnName": "num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "user",
+ "columnName": "user",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "longName",
+ "columnName": "long_name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "shortName",
+ "columnName": "short_name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "latitude",
+ "columnName": "latitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "longitude",
+ "columnName": "longitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "snr",
+ "columnName": "snr",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "rssi",
+ "columnName": "rssi",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastHeard",
+ "columnName": "last_heard",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deviceTelemetry",
+ "columnName": "device_metrics",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "channel",
+ "columnName": "channel",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "viaMqtt",
+ "columnName": "via_mqtt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hopsAway",
+ "columnName": "hops_away",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isFavorite",
+ "columnName": "is_favorite",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isIgnored",
+ "columnName": "is_ignored",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "isMuted",
+ "columnName": "is_muted",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "environmentTelemetry",
+ "columnName": "environment_metrics",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "powerTelemetry",
+ "columnName": "power_metrics",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "paxcounter",
+ "columnName": "paxcounter",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publicKey",
+ "columnName": "public_key",
+ "affinity": "BLOB"
+ },
+ {
+ "fieldPath": "notes",
+ "columnName": "notes",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "manuallyVerified",
+ "columnName": "manually_verified",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "nodeStatus",
+ "columnName": "node_status",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lastTransport",
+ "columnName": "last_transport",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "num"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_nodes_last_heard",
+ "unique": false,
+ "columnNames": [
+ "last_heard"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard` ON `${TABLE_NAME}` (`last_heard`)"
+ },
+ {
+ "name": "index_nodes_short_name",
+ "unique": false,
+ "columnNames": [
+ "short_name"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_short_name` ON `${TABLE_NAME}` (`short_name`)"
+ },
+ {
+ "name": "index_nodes_long_name",
+ "unique": false,
+ "columnNames": [
+ "long_name"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_long_name` ON `${TABLE_NAME}` (`long_name`)"
+ },
+ {
+ "name": "index_nodes_hops_away",
+ "unique": false,
+ "columnNames": [
+ "hops_away"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_hops_away` ON `${TABLE_NAME}` (`hops_away`)"
+ },
+ {
+ "name": "index_nodes_is_favorite",
+ "unique": false,
+ "columnNames": [
+ "is_favorite"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_is_favorite` ON `${TABLE_NAME}` (`is_favorite`)"
+ },
+ {
+ "name": "index_nodes_last_heard_is_favorite",
+ "unique": false,
+ "columnNames": [
+ "last_heard",
+ "is_favorite"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard_is_favorite` ON `${TABLE_NAME}` (`last_heard`, `is_favorite`)"
+ },
+ {
+ "name": "index_nodes_public_key",
+ "unique": false,
+ "columnNames": [
+ "public_key"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_public_key` ON `${TABLE_NAME}` (`public_key`)"
+ }
+ ]
+ },
+ {
+ "tableName": "packet",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `myNodeNum` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL, `contact_key` TEXT NOT NULL, `received_time` INTEGER NOT NULL, `read` INTEGER NOT NULL DEFAULT 1, `data` TEXT NOT NULL, `packet_id` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT -1, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `sfpp_hash` BLOB, `filtered` INTEGER NOT NULL DEFAULT 0, `message_text` TEXT NOT NULL DEFAULT '')",
+ "fields": [
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "myNodeNum",
+ "columnName": "myNodeNum",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "port_num",
+ "columnName": "port_num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "contact_key",
+ "columnName": "contact_key",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "received_time",
+ "columnName": "received_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "read",
+ "columnName": "read",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "1"
+ },
+ {
+ "fieldPath": "data",
+ "columnName": "data",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "packetId",
+ "columnName": "packet_id",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "routingError",
+ "columnName": "routing_error",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "-1"
+ },
+ {
+ "fieldPath": "snr",
+ "columnName": "snr",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "rssi",
+ "columnName": "rssi",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "hopsAway",
+ "columnName": "hopsAway",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "-1"
+ },
+ {
+ "fieldPath": "sfpp_hash",
+ "columnName": "sfpp_hash",
+ "affinity": "BLOB"
+ },
+ {
+ "fieldPath": "filtered",
+ "columnName": "filtered",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "messageText",
+ "columnName": "message_text",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "uuid"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_packet_myNodeNum",
+ "unique": false,
+ "columnNames": [
+ "myNodeNum"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_myNodeNum` ON `${TABLE_NAME}` (`myNodeNum`)"
+ },
+ {
+ "name": "index_packet_port_num",
+ "unique": false,
+ "columnNames": [
+ "port_num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_port_num` ON `${TABLE_NAME}` (`port_num`)"
+ },
+ {
+ "name": "index_packet_contact_key",
+ "unique": false,
+ "columnNames": [
+ "contact_key"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key` ON `${TABLE_NAME}` (`contact_key`)"
+ },
+ {
+ "name": "index_packet_contact_key_port_num_received_time",
+ "unique": false,
+ "columnNames": [
+ "contact_key",
+ "port_num",
+ "received_time"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key_port_num_received_time` ON `${TABLE_NAME}` (`contact_key`, `port_num`, `received_time`)"
+ },
+ {
+ "name": "index_packet_packet_id",
+ "unique": false,
+ "columnNames": [
+ "packet_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_packet_id` ON `${TABLE_NAME}` (`packet_id`)"
+ },
+ {
+ "name": "index_packet_received_time",
+ "unique": false,
+ "columnNames": [
+ "received_time"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_received_time` ON `${TABLE_NAME}` (`received_time`)"
+ },
+ {
+ "name": "index_packet_filtered",
+ "unique": false,
+ "columnNames": [
+ "filtered"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_filtered` ON `${TABLE_NAME}` (`filtered`)"
+ },
+ {
+ "name": "index_packet_read",
+ "unique": false,
+ "columnNames": [
+ "read"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_read` ON `${TABLE_NAME}` (`read`)"
+ }
+ ]
+ },
+ {
+ "tableName": "packet_fts",
+ "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS5(`message_text`, tokenize=`unicode61`, content=`packet`)",
+ "fields": [
+ {
+ "fieldPath": "messageText",
+ "columnName": "message_text",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": []
+ },
+ "ftsVersion": "FTS5",
+ "ftsOptions": {
+ "tokenizer": "unicode61",
+ "tokenizerArgs": [],
+ "contentTable": "packet",
+ "languageIdColumnName": "",
+ "matchInfo": "FTS4",
+ "notIndexedColumns": [],
+ "prefixSizes": [],
+ "preferredOrder": "ASC",
+ "contentRowId": "",
+ "columnSize": true,
+ "detail": "FULL"
+ },
+ "contentSyncTriggers": [
+ "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_BEFORE_UPDATE BEFORE UPDATE ON `packet` BEGIN DELETE FROM `packet_fts` WHERE `rowid`=OLD.`rowid`; END",
+ "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_BEFORE_DELETE BEFORE DELETE ON `packet` BEGIN DELETE FROM `packet_fts` WHERE `rowid`=OLD.`rowid`; END",
+ "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_AFTER_UPDATE AFTER UPDATE ON `packet` BEGIN INSERT INTO `packet_fts`(`rowid`, `message_text`) VALUES (NEW.`rowid`, NEW.`message_text`); END",
+ "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_AFTER_INSERT AFTER INSERT ON `packet` BEGIN INSERT INTO `packet_fts`(`rowid`, `message_text`) VALUES (NEW.`rowid`, NEW.`message_text`); END"
+ ]
+ },
+ {
+ "tableName": "contact_settings",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contact_key` TEXT NOT NULL, `muteUntil` INTEGER NOT NULL, `last_read_message_uuid` INTEGER, `last_read_message_timestamp` INTEGER, `filtering_disabled` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`contact_key`))",
+ "fields": [
+ {
+ "fieldPath": "contact_key",
+ "columnName": "contact_key",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "muteUntil",
+ "columnName": "muteUntil",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastReadMessageUuid",
+ "columnName": "last_read_message_uuid",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "lastReadMessageTimestamp",
+ "columnName": "last_read_message_timestamp",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "filteringDisabled",
+ "columnName": "filtering_disabled",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "contact_key"
+ ]
+ }
+ },
+ {
+ "tableName": "log",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `type` TEXT NOT NULL, `received_date` INTEGER NOT NULL, `message` TEXT NOT NULL, `from_num` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL DEFAULT 0, `from_radio` BLOB NOT NULL DEFAULT x'', PRIMARY KEY(`uuid`))",
+ "fields": [
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "message_type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "received_date",
+ "columnName": "received_date",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "raw_message",
+ "columnName": "message",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "fromNum",
+ "columnName": "from_num",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "portNum",
+ "columnName": "port_num",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "fromRadio",
+ "columnName": "from_radio",
+ "affinity": "BLOB",
+ "notNull": true,
+ "defaultValue": "x''"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "uuid"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_log_from_num",
+ "unique": false,
+ "columnNames": [
+ "from_num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_log_from_num` ON `${TABLE_NAME}` (`from_num`)"
+ },
+ {
+ "name": "index_log_port_num",
+ "unique": false,
+ "columnNames": [
+ "port_num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_log_port_num` ON `${TABLE_NAME}` (`port_num`)"
+ }
+ ]
+ },
+ {
+ "tableName": "quick_chat",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `message` TEXT NOT NULL, `mode` TEXT NOT NULL, `position` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "message",
+ "columnName": "message",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mode",
+ "columnName": "mode",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "uuid"
+ ]
+ }
+ },
+ {
+ "tableName": "reactions",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL DEFAULT 0, `reply_id` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `emoji` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `packet_id` INTEGER NOT NULL DEFAULT 0, `status` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT 0, `relays` INTEGER NOT NULL DEFAULT 0, `relay_node` INTEGER, `to` TEXT, `channel` INTEGER NOT NULL DEFAULT 0, `sfpp_hash` BLOB, PRIMARY KEY(`myNodeNum`, `reply_id`, `user_id`, `emoji`))",
+ "fields": [
+ {
+ "fieldPath": "myNodeNum",
+ "columnName": "myNodeNum",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "replyId",
+ "columnName": "reply_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userId",
+ "columnName": "user_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "emoji",
+ "columnName": "emoji",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "snr",
+ "columnName": "snr",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "rssi",
+ "columnName": "rssi",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "hopsAway",
+ "columnName": "hopsAway",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "-1"
+ },
+ {
+ "fieldPath": "packetId",
+ "columnName": "packet_id",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "routingError",
+ "columnName": "routing_error",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "relays",
+ "columnName": "relays",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "relayNode",
+ "columnName": "relay_node",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "to",
+ "columnName": "to",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "channel",
+ "columnName": "channel",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "sfpp_hash",
+ "columnName": "sfpp_hash",
+ "affinity": "BLOB"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "myNodeNum",
+ "reply_id",
+ "user_id",
+ "emoji"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_reactions_reply_id",
+ "unique": false,
+ "columnNames": [
+ "reply_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_reply_id` ON `${TABLE_NAME}` (`reply_id`)"
+ },
+ {
+ "name": "index_reactions_packet_id",
+ "unique": false,
+ "columnNames": [
+ "packet_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_packet_id` ON `${TABLE_NAME}` (`packet_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "metadata",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `proto` BLOB NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`num`))",
+ "fields": [
+ {
+ "fieldPath": "num",
+ "columnName": "num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "proto",
+ "columnName": "proto",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "num"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_metadata_num",
+ "unique": false,
+ "columnNames": [
+ "num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_metadata_num` ON `${TABLE_NAME}` (`num`)"
+ }
+ ]
+ },
+ {
+ "tableName": "device_hardware",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`actively_supported` INTEGER NOT NULL, `architecture` TEXT NOT NULL, `display_name` TEXT NOT NULL, `has_ink_hud` INTEGER, `has_mui` INTEGER, `hwModel` INTEGER NOT NULL, `hw_model_slug` TEXT NOT NULL, `images` TEXT, `last_updated` INTEGER NOT NULL, `partition_scheme` TEXT, `platformio_target` TEXT NOT NULL, `requires_dfu` INTEGER, `support_level` INTEGER, `tags` TEXT, PRIMARY KEY(`platformio_target`))",
+ "fields": [
+ {
+ "fieldPath": "activelySupported",
+ "columnName": "actively_supported",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "architecture",
+ "columnName": "architecture",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "displayName",
+ "columnName": "display_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hasInkHud",
+ "columnName": "has_ink_hud",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "hasMui",
+ "columnName": "has_mui",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "hwModel",
+ "columnName": "hwModel",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hwModelSlug",
+ "columnName": "hw_model_slug",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "images",
+ "columnName": "images",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lastUpdated",
+ "columnName": "last_updated",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "partitionScheme",
+ "columnName": "partition_scheme",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "platformioTarget",
+ "columnName": "platformio_target",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "requiresDfu",
+ "columnName": "requires_dfu",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "supportLevel",
+ "columnName": "support_level",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "tags",
+ "columnName": "tags",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "platformio_target"
+ ]
+ }
+ },
+ {
+ "tableName": "firmware_release",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `page_url` TEXT NOT NULL, `release_notes` TEXT NOT NULL, `title` TEXT NOT NULL, `zip_url` TEXT NOT NULL, `last_updated` INTEGER NOT NULL, `release_type` TEXT NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "pageUrl",
+ "columnName": "page_url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "releaseNotes",
+ "columnName": "release_notes",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "zipUrl",
+ "columnName": "zip_url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastUpdated",
+ "columnName": "last_updated",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "releaseType",
+ "columnName": "release_type",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "traceroute_node_position",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`log_uuid` TEXT NOT NULL, `request_id` INTEGER NOT NULL, `node_num` INTEGER NOT NULL, `position` BLOB NOT NULL, PRIMARY KEY(`log_uuid`, `node_num`), FOREIGN KEY(`log_uuid`) REFERENCES `log`(`uuid`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "logUuid",
+ "columnName": "log_uuid",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "requestId",
+ "columnName": "request_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "nodeNum",
+ "columnName": "node_num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "BLOB",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "log_uuid",
+ "node_num"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_traceroute_node_position_log_uuid",
+ "unique": false,
+ "columnNames": [
+ "log_uuid"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_log_uuid` ON `${TABLE_NAME}` (`log_uuid`)"
+ },
+ {
+ "name": "index_traceroute_node_position_request_id",
+ "unique": false,
+ "columnNames": [
+ "request_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_request_id` ON `${TABLE_NAME}` (`request_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "log",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "log_uuid"
+ ],
+ "referencedColumns": [
+ "uuid"
+ ]
+ }
+ ]
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '69fb4477a86e5ba8c47876cbb3035839')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt b/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt
index 451a621740..f992cce6d4 100644
--- a/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt
+++ b/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt
@@ -17,6 +17,7 @@
package org.meshtastic.core.database.dao
import androidx.room3.Room
+import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.flow.first
@@ -66,6 +67,7 @@ class MigrationTest {
context = context,
factory = { MeshtasticDatabaseConstructor.initialize() },
)
+ .setDriver(BundledSQLiteDriver())
.build()
nodeInfoDao = database.nodeInfoDao().apply { setMyNodeInfo(myNodeInfo) }
packetDao = database.packetDao()
diff --git a/core/database/src/androidMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt b/core/database/src/androidMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt
index 84e00ca696..8e9fbbac23 100644
--- a/core/database/src/androidMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt
+++ b/core/database/src/androidMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt
@@ -24,6 +24,7 @@ import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.preferencesDataStoreFile
import androidx.room3.Room
import androidx.room3.RoomDatabase
+import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import okio.FileSystem
import okio.Path
import okio.Path.Companion.toPath
@@ -38,12 +39,14 @@ actual fun getDatabaseBuilder(dbName: String): RoomDatabase.Builder<MeshtasticDa
factory = { MeshtasticDatabaseConstructor.initialize() },
)
.configureCommon()
+ .setDriver(BundledSQLiteDriver())
}
/** Returns a [RoomDatabase.Builder] configured for an in-memory Android database. */
actual fun getInMemoryDatabaseBuilder(): RoomDatabase.Builder<MeshtasticDatabase> =
Room.inMemoryDatabaseBuilder<MeshtasticDatabase>(factory = { MeshtasticDatabaseConstructor.initialize() })
.configureCommon()
+ .setDriver(BundledSQLiteDriver())
/** Returns the Android directory where database files are stored. */
actual fun getDatabaseDirectory(): Path {
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt
index 42dd56a6af..92db4c4e7c 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt
@@ -26,6 +26,7 @@ import co.touchlab.kermit.Logger
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
@@ -149,6 +150,9 @@ open class DatabaseManager(
// One-time cleanup: remove legacy DB if present and not active
managerScope.launch(dispatchers.io) { cleanupLegacyDbIfNeeded(activeDbName = dbName) }
+ // Backfill FTS search index for any text messages missing messageText
+ managerScope.launch(dispatchers.io) { backfillSearchIndexIfNeeded(db) }
+
Logger.i { "Switched active DB to ${anonymizeDbName(dbName)} for address ${anonymizeAddress(address)}" }
}
@@ -305,6 +309,27 @@ open class DatabaseManager(
datastore.edit { it[legacyCleanedKey] = true }
}
+ /**
+ * Backfills [Packet.messageText] for existing text-message packets that predate the FTS5 schema. Uses a single SQL
+ * UPDATE with json_extract to avoid loading all packets into memory, then rebuilds the FTS index so search covers
+ * historical messages.
+ */
+ private suspend fun backfillSearchIndexIfNeeded(db: MeshtasticDatabase) {
+ val needsBackfill = db.packetDao().countPacketsNeedingBackfill() > 0
+ if (!needsBackfill) return
+
+ // Perform the write operations inside NonCancellable to prevent
+ // connection pool leaks due to coroutine cancellation.
+ withContext(NonCancellable) {
+ val count = db.packetDao().backfillMessageTexts()
+ if (count > 0) {
+ Logger.i { "Backfilled $count messages for FTS search index" }
+ db.packetDao().rebuildFtsIndex()
+ Logger.i { "FTS search index rebuild complete" }
+ }
+ }
+ }
+
/** Closes all open databases and cancels background work. */
fun close() {
managerScope.cancel()
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt
index b46d3b360a..6eade94889 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt
@@ -39,6 +39,7 @@ import org.meshtastic.core.database.entity.MetadataEntity
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.database.entity.Packet
+import org.meshtastic.core.database.entity.PacketFts
import org.meshtastic.core.database.entity.QuickChatAction
import org.meshtastic.core.database.entity.ReactionEntity
import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
@@ -49,6 +50,7 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
MyNodeEntity::class,
NodeEntity::class,
Packet::class,
+ PacketFts::class,
ContactSettings::class,
MeshLog::class,
QuickChatAction::class,
@@ -95,8 +97,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
AutoMigration(from = 35, to = 36),
AutoMigration(from = 36, to = 37),
AutoMigration(from = 37, to = 38),
+ AutoMigration(from = 38, to = 39),
],
- version = 38,
+ version = 39,
exportSchema = true,
)
@androidx.room3.ConstructedBy(MeshtasticDatabaseConstructor::class)
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt
index 2aef7ef6d2..29068a7c16 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt
@@ -547,4 +547,43 @@ interface PacketDao {
"UPDATE packet SET filtered = :filtered WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) AND data LIKE :senderIdPattern",
)
suspend fun updateFilteredBySender(senderIdPattern: String, filtered: Boolean)
+
+ // region โโ FTS5 Search โโ
+
+ @Query(
+ "SELECT packet.* FROM packet JOIN packet_fts ON packet.rowid = packet_fts.rowid " +
+ "WHERE packet_fts MATCH :query AND packet.myNodeNum = (SELECT myNodeNum FROM my_node) " +
+ "ORDER BY packet.received_time DESC LIMIT 100",
+ )
+ suspend fun searchMessages(query: String): List<Packet>
+
+ @Query(
+ "SELECT packet.* FROM packet JOIN packet_fts ON packet.rowid = packet_fts.rowid " +
+ "WHERE packet_fts MATCH :query AND packet.contact_key = :contactKey " +
+ "AND packet.myNodeNum = (SELECT myNodeNum FROM my_node) " +
+ "ORDER BY packet.received_time DESC LIMIT 100",
+ )
+ suspend fun searchMessagesInConversation(query: String, contactKey: String): List<Packet>
+
+ @Query("UPDATE packet SET message_text = :text WHERE uuid = :uuid")
+ suspend fun updateMessageText(uuid: Long, text: String)
+
+ @Query(
+ "SELECT COUNT(*) FROM packet " +
+ "WHERE port_num = 1 AND (message_text IS NULL OR message_text = '') " +
+ "AND json_extract(data, '\$.text') IS NOT NULL",
+ )
+ suspend fun countPacketsNeedingBackfill(): Int
+
+ @Query(
+ "UPDATE packet SET message_text = json_extract(data, '\$.text') " +
+ "WHERE port_num = 1 AND (message_text IS NULL OR message_text = '') " +
+ "AND json_extract(data, '\$.text') IS NOT NULL",
+ )
+ suspend fun backfillMessageTexts(): Int
+
+ @Query("INSERT INTO packet_fts(packet_fts) VALUES('rebuild')")
+ suspend fun rebuildFtsIndex()
+
+ // endregion
}
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt
index 5a16fd7b1a..e4b7727524 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt
@@ -94,6 +94,7 @@ data class Packet(
@ColumnInfo(name = "hopsAway", defaultValue = "-1") val hopsAway: Int = -1,
@ColumnInfo(name = "sfpp_hash") val sfpp_hash: ByteString? = null,
@ColumnInfo(name = "filtered", defaultValue = "0") val filtered: Boolean = false,
+ @ColumnInfo(name = "message_text", defaultValue = "") val messageText: String = "",
) {
companion object {
const val RELAY_NODE_SUFFIX_MASK = 0xFF
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/PacketFts.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/PacketFts.kt
new file mode 100644
index 0000000000..1e7e545835
--- /dev/null
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/PacketFts.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.database.entity
+
+import androidx.room3.ColumnInfo
+import androidx.room3.Entity
+import androidx.room3.Fts5
+
+/**
+ * FTS5 virtual table that mirrors [Packet.messageText] for full-text search. Room auto-generates INSERT/UPDATE/DELETE
+ * triggers to keep this table in sync with the content entity ([Packet]).
+ */
+@Fts5(contentEntity = Packet::class)
+@Entity(tableName = "packet_fts")
+data class PacketFts(@ColumnInfo(name = "message_text") val messageText: String)
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt
index 491c3e193f..4f83ff2abe 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt
@@ -216,4 +216,14 @@ interface PacketRepository {
/** Updates the SFPP status of packets matching the given commit hash. */
suspend fun updateSFPPStatusByHash(hash: ByteArray, status: MessageStatus, rxTime: Long)
+
+ /**
+ * Searches message history using full-text search.
+ *
+ * @param query The search text (will be sanitized for FTS5).
+ * @param contactKey Optional contact key to scope search to a single conversation.
+ * @param getNode Function to resolve node info by userId.
+ * @return Flow emitting matching messages.
+ */
+ fun searchMessages(query: String, contactKey: String? = null, getNode: (String?) -> Node): Flow<List<Message>>
}
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 1d32c061b5..414be1f995 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -1172,6 +1172,7 @@
<string name="screen_on_for">Screen on for</string>
<string name="scroll_to_bottom">Scroll to bottom</string>
<string name="search_emoji">Search emoji...</string>
+ <string name="search_messages">Search messagesโฆ</string>
<string name="secondary">Secondary</string>
<string name="secondary_channel_position_feature">Disabling position on the primary channel allows periodic position broadcasts on the first secondary channel with the position enabled, otherwise manual position request required.</string>
<string name="secondary_no_telemetry">No periodic telemetry broadcast</string>
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/AutoLinkText.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/AutoLinkText.kt
index 7ee7dcb02d..af4909e263 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/AutoLinkText.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/AutoLinkText.kt
@@ -16,6 +16,7 @@
*/
package org.meshtastic.core.ui.component
+import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
@@ -27,8 +28,10 @@ import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.text.withStyle
import org.meshtastic.core.ui.theme.HyperlinkBlue
private val DefaultTextLinkStyles =
@@ -90,3 +93,77 @@ private fun buildAnnotatedStringWithLinks(text: String, linkStyles: TextLinkStyl
range.forEach { usedIndices.add(it) }
}
}
+
+/**
+ * A [Text] component that highlights occurrences of [query] within [text] using the tertiary container color. Each
+ * matching token in the query is highlighted independently (case-insensitive).
+ */
+@Composable
+fun HighlightedText(
+ text: String,
+ query: String,
+ modifier: Modifier = Modifier,
+ style: TextStyle = TextStyle.Default,
+ color: Color = Color.Unspecified,
+) {
+ val highlightColor = MaterialTheme.colorScheme.tertiaryContainer
+ val highlightContentColor = MaterialTheme.colorScheme.onTertiaryContainer
+ val annotatedString =
+ remember(text, query, highlightColor, highlightContentColor) {
+ buildHighlightedString(text, query, highlightColor, highlightContentColor)
+ }
+ Text(text = annotatedString, modifier = modifier, style = style.copy(color = color))
+}
+
+private fun buildHighlightedString(
+ text: String,
+ query: String,
+ highlightColor: Color,
+ contentColor: Color,
+): AnnotatedString = buildAnnotatedString {
+ val lowerText = text.lowercase()
+ val tokens = query.split("\\s+".toRegex()).filter { it.isNotBlank() }.map { it.lowercase() }
+ if (tokens.isEmpty()) {
+ append(text)
+ return@buildAnnotatedString
+ }
+
+ // Find all match ranges
+ val matchRanges = mutableListOf<IntRange>()
+ for (token in tokens) {
+ var start = 0
+ while (start < lowerText.length) {
+ val matchStart = lowerText.indexOf(token, start)
+ if (matchStart == -1) break
+ matchRanges.add(matchStart until matchStart + token.length)
+ start = matchStart + token.length
+ }
+ }
+
+ // Merge overlapping ranges and sort
+ val merged = mergeRanges(matchRanges.sortedBy { it.first })
+
+ val highlightStyle = SpanStyle(background = highlightColor, color = contentColor, fontWeight = FontWeight.Bold)
+
+ var cursor = 0
+ for (range in merged) {
+ if (range.first > cursor) append(text.substring(cursor, range.first))
+ withStyle(highlightStyle) { append(text.substring(range.first, range.last + 1)) }
+ cursor = range.last + 1
+ }
+ if (cursor < text.length) append(text.substring(cursor))
+}
+
+private fun mergeRanges(sorted: List<IntRange>): List<IntRange> {
+ if (sorted.isEmpty()) return emptyList()
+ val result = mutableListOf(sorted.first())
+ for (range in sorted.drop(1)) {
+ val last = result.last()
+ if (range.first <= last.last + 1) {
+ result[result.lastIndex] = last.first..maxOf(last.last, range.last)
+ } else {
+ result.add(range)
+ }
+ }
+ return result
+}
diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt
index 867dd831fa..4dab1eec39 100644
--- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt
@@ -91,6 +91,7 @@ import org.meshtastic.feature.messaging.component.ActionModeTopBar
import org.meshtastic.feature.messaging.component.DeleteMessageDialog
import org.meshtastic.feature.messaging.component.MESSAGE_CHARACTER_LIMIT_BYTES
import org.meshtastic.feature.messaging.component.MessageMenuAction
+import org.meshtastic.feature.messaging.component.MessageSearchBar
import org.meshtastic.feature.messaging.component.MessageTopBar
import org.meshtastic.feature.messaging.component.QuickChatRow
import org.meshtastic.feature.messaging.component.ReplySnippet
@@ -144,6 +145,11 @@ fun MessageScreen(
val filteredCount by viewModel.filteredCount.collectAsStateWithLifecycle()
val showFiltered by viewModel.showFiltered.collectAsStateWithLifecycle()
val filteringDisabled = contactSettings[contactKey]?.filteringDisabled ?: false
+ val isSearchActive by viewModel.isSearchActive.collectAsStateWithLifecycle()
+ val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle()
+ val searchResults by viewModel.searchResults.collectAsStateWithLifecycle()
+ val searchResultIndex by viewModel.searchResultIndex.collectAsStateWithLifecycle()
+ val currentSearchResult by viewModel.currentSearchResult.collectAsStateWithLifecycle()
// Sync text field changes back to ViewModel draft
LaunchedEffect(messageInputState) {
@@ -231,6 +237,15 @@ fun MessageScreen(
}
}
+ // Scroll to the current search result when navigating prev/next
+ LaunchedEffect(currentSearchResult) {
+ val targetUuid = currentSearchResult?.uuid ?: return@LaunchedEffect
+ val index = pagedMessages.itemSnapshotList.indexOfFirst { it?.uuid == targetUuid }
+ if (index != -1) {
+ listState.animateScrollToItem(index)
+ }
+ }
+
val onEvent: (MessageScreenEvent) -> Unit =
remember(viewModel, contactKey, messageInputState, ourNode) {
fun handle(event: MessageScreenEvent) {
@@ -324,6 +339,16 @@ fun MessageScreen(
}
},
)
+ } else if (isSearchActive) {
+ MessageSearchBar(
+ query = searchQuery,
+ onQueryChange = viewModel::setSearchQuery,
+ onClose = viewModel::closeSearch,
+ resultCount = searchResults.size,
+ currentIndex = searchResultIndex,
+ onPrevious = viewModel::navigateToPreviousResult,
+ onNext = viewModel::navigateToNextResult,
+ )
} else {
MessageTopBar(
title = title,
@@ -343,6 +368,7 @@ fun MessageScreen(
showFiltered = showFiltered,
onToggleShowFiltered = viewModel::toggleShowFiltered,
onNavigateToFilterSettings = navigateToFilterSettings,
+ onSearchClick = viewModel::toggleSearch,
)
}
},
@@ -396,6 +422,7 @@ fun MessageScreen(
filteredCount = filteredCount,
showFiltered = showFiltered,
filteringDisabled = filteringDisabled,
+ searchQuery = if (isSearchActive) searchQuery else "",
),
handlers =
MessageListHandlers(
diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt
index 3f92f3cbf7..d8de5bb955 100644
--- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt
@@ -82,6 +82,7 @@ internal data class MessageListPagedState(
val filteredCount: Int = 0,
val showFiltered: Boolean = false,
val filteringDisabled: Boolean = false,
+ val searchQuery: String = "",
)
private fun MutableState<Set<Long>>.toggle(uuid: Long) {
@@ -367,6 +368,7 @@ private fun RenderPagedChatMessageRow(
hasSamePrev = hasSamePrev,
hasSameNext = hasSameNext,
quickEmojis = quickEmojis,
+ searchQuery = state.searchQuery,
)
}
diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt
index 10720a86a4..42fc2722ed 100644
--- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt
@@ -21,15 +21,18 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
+import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.update
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.util.ioDispatcher
@@ -156,6 +159,68 @@ class MessageViewModel(
.flatMapLatest { packetRepository.getFilteredCountFlow(it) }
.stateInWhileSubscribed(0)
+ // region โโ Search โโ
+
+ private val _searchQuery = MutableStateFlow("")
+ val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
+
+ private val _isSearchActive = MutableStateFlow(false)
+ val isSearchActive: StateFlow<Boolean> = _isSearchActive.asStateFlow()
+
+ private val _searchResultIndex = MutableStateFlow(0)
+ val searchResultIndex: StateFlow<Int> = _searchResultIndex.asStateFlow()
+
+ @OptIn(FlowPreview::class)
+ val searchResults: StateFlow<List<Message>> =
+ combine(_searchQuery, contactKeyForPagedMessages) { query, contactKey -> query to contactKey }
+ .debounce(SEARCH_DEBOUNCE_MS)
+ .flatMapLatest { (query, contactKey) ->
+ if (query.length < MIN_SEARCH_LENGTH) {
+ flowOf(emptyList())
+ } else {
+ packetRepository.searchMessages(query, contactKey, ::getNode)
+ }
+ }
+ .stateInWhileSubscribed(emptyList())
+
+ /** The currently focused search result message (for scroll-to-match). */
+ val currentSearchResult: StateFlow<Message?> =
+ combine(searchResults, _searchResultIndex) { results, index -> results.getOrNull(index) }
+ .stateInWhileSubscribed(null)
+
+ fun setSearchQuery(query: String) {
+ _searchQuery.value = query
+ _searchResultIndex.value = 0
+ }
+
+ fun navigateToNextResult() {
+ val max = searchResults.value.size
+ if (max == 0) return
+ _searchResultIndex.update { (it + 1) % max }
+ }
+
+ fun navigateToPreviousResult() {
+ val max = searchResults.value.size
+ if (max == 0) return
+ _searchResultIndex.update { if (it == 0) max - 1 else it - 1 }
+ }
+
+ fun toggleSearch() {
+ _isSearchActive.value = !_isSearchActive.value
+ if (!_isSearchActive.value) {
+ _searchQuery.value = ""
+ _searchResultIndex.value = 0
+ }
+ }
+
+ fun closeSearch() {
+ _isSearchActive.value = false
+ _searchQuery.value = ""
+ _searchResultIndex.value = 0
+ }
+
+ // endregion
+
init {
val contactKey = savedStateHandle.get<String>("contactKey")
if (contactKey != null) {
@@ -247,4 +312,9 @@ class MessageViewModel(
val unreadCount = packetRepository.getUnreadCount(contact)
if (unreadCount == 0) notificationManager.cancel(contact.hashCode())
}
+
+ companion object {
+ private const val SEARCH_DEBOUNCE_MS = 300L
+ private const val MIN_SEARCH_LENGTH = 2
+ }
}
diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt
index 81e29fb09b..ff93c3e738 100644
--- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt
@@ -66,6 +66,7 @@ import org.meshtastic.core.resources.a11y_message_from
import org.meshtastic.core.resources.filter_message_label
import org.meshtastic.core.resources.reply
import org.meshtastic.core.ui.component.AutoLinkText
+import org.meshtastic.core.ui.component.HighlightedText
import org.meshtastic.core.ui.component.NodeChip
import org.meshtastic.core.ui.component.Rssi
import org.meshtastic.core.ui.component.Snr
@@ -103,6 +104,7 @@ fun MessageItem(
onStatusClick: () -> Unit = {},
hasSamePrev: Boolean = false,
hasSameNext: Boolean = false,
+ searchQuery: String = "",
) = Column(
modifier =
modifier
@@ -260,7 +262,20 @@ fun MessageItem(
)
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)) {
- AutoLinkText(text = message.text, style = MaterialTheme.typography.bodyLarge, color = contentColor)
+ if (searchQuery.isNotEmpty()) {
+ HighlightedText(
+ text = message.text,
+ query = searchQuery,
+ style = MaterialTheme.typography.bodyLarge,
+ color = contentColor,
+ )
+ } else {
+ AutoLinkText(
+ text = message.text,
+ style = MaterialTheme.typography.bodyLarge,
+ color = contentColor,
+ )
+ }
Row(modifier = Modifier, verticalAlignment = Alignment.CenterVertically) {
if (!message.fromLocal) {
diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageScreenComponents.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageScreenComponents.kt
index 4d89b342c2..2482c3341d 100644
--- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageScreenComponents.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageScreenComponents.kt
@@ -48,6 +48,8 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuDefaults
import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -57,6 +59,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CoroutineScope
@@ -70,6 +73,7 @@ import org.meshtastic.core.model.Node
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.alert_bell_text
import org.meshtastic.core.resources.cancel_reply
+import org.meshtastic.core.resources.clear
import org.meshtastic.core.resources.clear_selection
import org.meshtastic.core.resources.copy
import org.meshtastic.core.resources.delete
@@ -89,6 +93,7 @@ import org.meshtastic.core.resources.quick_chat_show
import org.meshtastic.core.resources.reply
import org.meshtastic.core.resources.replying_to
import org.meshtastic.core.resources.scroll_to_bottom
+import org.meshtastic.core.resources.search_messages
import org.meshtastic.core.resources.select_all
import org.meshtastic.core.resources.unknown
import org.meshtastic.core.ui.component.MeshtasticTextDialog
@@ -102,10 +107,13 @@ import org.meshtastic.core.ui.icon.Copy
import org.meshtastic.core.ui.icon.Delete
import org.meshtastic.core.ui.icon.FilterList
import org.meshtastic.core.ui.icon.FilterListOff
+import org.meshtastic.core.ui.icon.KeyboardArrowDown
+import org.meshtastic.core.ui.icon.KeyboardArrowUp
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.More
import org.meshtastic.core.ui.icon.Muted
import org.meshtastic.core.ui.icon.Reply
+import org.meshtastic.core.ui.icon.Search
import org.meshtastic.core.ui.icon.SelectAll
import org.meshtastic.core.ui.icon.Settings
import org.meshtastic.core.ui.icon.Unmuted
@@ -303,6 +311,7 @@ fun MessageTopBar(
showFiltered: Boolean = false,
onToggleShowFiltered: () -> Unit = {},
onNavigateToFilterSettings: () -> Unit = {},
+ onSearchClick: () -> Unit = {},
) = TopAppBar(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -323,6 +332,12 @@ fun MessageTopBar(
}
},
actions = {
+ IconButton(onClick = onSearchClick) {
+ Icon(
+ imageVector = MeshtasticIcons.Search,
+ contentDescription = stringResource(Res.string.search_messages),
+ )
+ }
MessageTopBarActions(
showQuickChat = showQuickChat,
onToggleQuickChat = onToggleQuickChat,
@@ -650,3 +665,85 @@ fun String.limitBytes(maxBytes: Int): String {
}
// endregion
+
+// region โโ MessageSearchBar โโ
+
+/**
+ * M3 contextual search bar that replaces the standard MessageTopBar when search is active. Follows the M3 "find in
+ * page" pattern: back arrow + text field + "X of Y" counter + prev/next arrows + clear.
+ *
+ * This uses [TopAppBar] rather than [SearchBar] because we're filtering within an existing conversation (contextual
+ * search), not performing primary app-level navigation search.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun MessageSearchBar(
+ query: String,
+ onQueryChange: (String) -> Unit,
+ onClose: () -> Unit,
+ resultCount: Int,
+ currentIndex: Int = 0,
+ onPrevious: () -> Unit = {},
+ onNext: () -> Unit = {},
+ modifier: Modifier = Modifier,
+) {
+ TopAppBar(
+ modifier = modifier,
+ navigationIcon = {
+ IconButton(onClick = onClose) {
+ Icon(
+ imageVector = MeshtasticIcons.ArrowBack,
+ contentDescription = stringResource(Res.string.navigate_back),
+ )
+ }
+ },
+ title = {
+ TextField(
+ value = query,
+ onValueChange = onQueryChange,
+ modifier = Modifier.fillMaxWidth(),
+ placeholder = {
+ Text(text = stringResource(Res.string.search_messages), style = MaterialTheme.typography.bodyLarge)
+ },
+ singleLine = true,
+ textStyle = MaterialTheme.typography.bodyLarge,
+ colors =
+ TextFieldDefaults.colors(
+ focusedContainerColor = Color.Transparent,
+ unfocusedContainerColor = Color.Transparent,
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent,
+ ),
+ )
+ },
+ actions = {
+ if (query.isNotEmpty() && resultCount > 0) {
+ Text(
+ text = "${currentIndex + 1} / $resultCount",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(end = 4.dp),
+ )
+ IconButton(onClick = onPrevious) {
+ Icon(
+ imageVector = MeshtasticIcons.KeyboardArrowUp,
+ contentDescription = stringResource(Res.string.search_messages),
+ )
+ }
+ IconButton(onClick = onNext) {
+ Icon(
+ imageVector = MeshtasticIcons.KeyboardArrowDown,
+ contentDescription = stringResource(Res.string.search_messages),
+ )
+ }
+ }
+ if (query.isNotEmpty()) {
+ IconButton(onClick = { onQueryChange("") }) {
+ Icon(imageVector = MeshtasticIcons.Close, contentDescription = stringResource(Res.string.clear))
+ }
+ }
+ },
+ )
+}
+
+// endregion
Served by rngit 1.5.0 - Generated in 0.29s